--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 913a051f416ec5d3f9a1359d5ec2c2e7c82f027c
Parents : 0e096d8
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-13T09:58:14-05:00
feat: implement RSM signing and verification process with associated CI tasks
Changes
11 files changed, 434 insertions(+), 1 deletions(-)
Diff
diff --git a/.github/actions/setup-dev-environment/action.yml b/.github/actions/setup-dev-environment/action.yml
index 926f7b05..25533621 100644
--- a/.github/actions/setup-dev-environment/action.yml
+++ b/.github/actions/setup-dev-environment/action.yml
@@ -24,6 +24,10 @@ inputs:
description: Install go-task for Taskfile.yml targets
required: false
default: "true"
+ skip_tree_verify:
+ description: Set to true to skip meshchatx.rsm byte-level verify
+ required: false
+ default: "false"
runs:
using: composite
@@ -48,3 +52,11 @@ runs:
if: inputs.install-task == 'true'
shell: bash
run: sh scripts/ci/setup-task.sh "${{ inputs.task-version }}"
+
+ - name: Verify tree RSM
+ if: inputs.skip_tree_verify != 'true'
+ shell: bash
+ env:
+ RNS_REQUIRED_SIGNER: e46112d44649266d71fe2193e00a4710
+ RNS_INVENTORY_OUT: ${{ runner.temp }}/meshchatx-tree-inventory.txt
+ run: sh scripts/ci/verify-tree-rsm.sh
diff --git a/.github/actions/verify-workspace-clean/action.yml b/.github/actions/verify-workspace-clean/action.yml
new file mode 100644
index 00000000..68953cc3
--- /dev/null
+++ b/.github/actions/verify-workspace-clean/action.yml
@@ -0,0 +1,10 @@
+name: Verify workspace clean
+description: Recheck byte-level tree inventory and reject unexpected runner mutations
+runs:
+ using: composite
+ steps:
+ - name: Verify workspace clean
+ shell: bash
+ env:
+ RNS_INVENTORY_OUT: ${{ runner.temp }}/meshchatx-tree-inventory.txt
+ run: sh scripts/ci/verify-workspace-clean.sh "$RNS_INVENTORY_OUT"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 69dc5bab..5236106f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,6 +36,7 @@ env:
NODE_VERSION: "24"
UV_VERSION: "0.11.15"
PNPM_VERSION: "11.1.2"
+ RNS_REQUIRED_SIGNER: e46112d44649266d71fe2193e00a4710
jobs:
frontend:
@@ -98,6 +99,9 @@ jobs:
;;
esac
+ - name: Verify workspace clean
+ uses: ./.github/actions/verify-workspace-clean
+
backend-tests:
name: Backend tests (Python 3.14) - Shard ${{ matrix.shard }} / 4
runs-on: ubuntu-latest
@@ -126,6 +130,9 @@ jobs:
PYTEST_TOTAL_SHARDS: 4
run: task test:backend
+ - name: Verify workspace clean
+ uses: ./.github/actions/verify-workspace-clean
+
build-check:
name: Linux build check
runs-on: ubuntu-latest
@@ -162,6 +169,9 @@ jobs:
- name: Verify bleak bundled in frozen backend
run: bash scripts/ci/github-verify-frozen-bleak.sh build/exe
+ - name: Verify workspace clean
+ uses: ./.github/actions/verify-workspace-clean
+
- name: Upload Linux build-check artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
diff --git a/.gitignore b/.gitignore
index d336476a..32d72c53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,4 +159,10 @@ electron/backend-manifest.json
scripts/private/
-*.exe
\ No newline at end of file
+*.exe
+# Local identity and rnid-style artifacts
+*.rid
+*.rsg
+*.rsm
+*.rfe
+!/meshchatx.rsm
diff --git a/SECURITY.md b/SECURITY.md
index e3dd1dba..878d486c 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -32,6 +32,24 @@ Official release binaries and packages are built in **automation on GitHub**, no
**Optional extra signatures:** If you see `*.cosign.bundle` files next to a binary, those are additional attestations from a **repository-managed signing key** (when the project enables it). They are separate from the SLSA `*.intoto.jsonl` files. Either or both may be present depending on configuration.
+
+### Source tree integrity (`.rsm`)
+
+The repository root includes a signed rnid message file, `meshchatx.rsm`. It embeds a SHA-256 inventory of every git-tracked file (except itself). CI verifies the signature against the required signer identity `e46112d44649266d71fe2193e00a4710`, then re-hashes file bytes. Jobs also recheck the inventory at the end so a compromised runner cannot silently add or modify tracked files.
+
+Verify locally:
+
+```bash
+task tree-rsm-verify
+```
+
+Maintainers regenerate the signature after intentional tree changes (requires a private identity file that hashes to the signer above, never commit `*.rid`):
+
+```bash
+export RNS_ID_PATH="$HOME/.local/share/reticulum-go/reticulum-go-release.rid"
+task tree-rsm-sign
+```
+
### Practical tips
- Prefer **official download pages** or **GitHub Releases** for your copy of the app.
diff --git a/Taskfile.yml b/Taskfile.yml
index 3a409c1b..c6783d22 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -612,6 +612,27 @@ tasks:
- cd "{{.ANDROID_DIR}}" && ./gradlew clean
- rm -rf "{{.PYTHON_SRC_DIR}}/meshchatx" "{{.ANDROID_DIR}}/app/src/main/python/meshchatx"
+
+ tree-manifest:
+ desc: Print SHA-256 inventory of git-tracked files (excludes meshchatx.rsm)
+ cmds:
+ - sh scripts/ci/tree-manifest.sh generate
+
+ tree-rsm-sign:
+ desc: Sign tree inventory into meshchatx.rsm (requires RNS_ID_PATH)
+ cmds:
+ - sh scripts/ci/sign-tree-rsm.sh
+
+ tree-rsm-verify:
+ desc: Verify meshchatx.rsm signature and byte-level file hashes
+ cmds:
+ - sh scripts/ci/verify-tree-rsm.sh
+
+ tree-workspace-clean:
+ desc: Recheck inventory and fail on unexpected workspace changes
+ cmds:
+ - sh scripts/ci/verify-workspace-clean.sh "${RNS_INVENTORY_OUT:-/tmp/meshchatx-tree-inventory.txt}"
+
# --- Maintenance ---
dist:
diff --git a/meshchatx.rsm b/meshchatx.rsm
new file mode 100644
index 00000000..90caa52b
Binary files /dev/null and b/meshchatx.rsm differ
diff --git a/scripts/ci/sign-tree-rsm.sh b/scripts/ci/sign-tree-rsm.sh
new file mode 100755
index 00000000..165714cf
--- /dev/null
+++ b/scripts/ci/sign-tree-rsm.sh
@@ -0,0 +1,66 @@
+#!/bin/sh
+# Sign a byte-level tree inventory into meshchatx.rsm (rnid-compatible).
+#
+# Requires a private identity file (64-byte .rid / rngit client_identity).
+# Never commit the identity file.
+#
+# Env:
+# RNS_ID_PATH path to identity (required unless -i given)
+# RNS_RSM_PATH output path (default: meshchatx.rsm in repo root)
+#
+# Usage:
+# RNS_ID_PATH=~/.local/share/reticulum-go/reticulum-go-release.rid sh scripts/ci/sign-tree-rsm.sh
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+ID_PATH="${RNS_ID_PATH:-}"
+RSM_PATH="${RNS_RSM_PATH:-$ROOT/meshchatx.rsm}"
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -i)
+ ID_PATH="${2:?}"
+ shift 2
+ ;;
+ -o)
+ RSM_PATH="${2:?}"
+ shift 2
+ ;;
+ *)
+ echo "sign-tree-rsm.sh: unknown arg: $1" >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [ -z "$ID_PATH" ]; then
+ echo "sign-tree-rsm.sh: set RNS_ID_PATH or pass -i /path/to.rid" >&2
+ exit 1
+fi
+if [ ! -f "$ID_PATH" ]; then
+ echo "sign-tree-rsm.sh: identity not found: $ID_PATH" >&2
+ exit 1
+fi
+
+run_rnid() {
+ if command -v rnid >/dev/null 2>&1; then
+ rnid "$@"
+ elif [ -x "$ROOT/.venv/bin/rnid" ]; then
+ "$ROOT/.venv/bin/rnid" "$@"
+ elif command -v uv >/dev/null 2>&1; then
+ uv run rnid "$@"
+ else
+ echo "sign-tree-rsm.sh: rnid not found (install rns or sync the venv)" >&2
+ return 1
+ fi
+}
+
+INV="$(mktemp "${TMPDIR:-/tmp}/tree-inv.XXXXXX")"
+trap 'rm -f "$INV"' EXIT INT
+
+sh "$ROOT/scripts/ci/tree-manifest.sh" generate >"$INV"
+# Bare -S with -r reads the message body from the inventory file.
+run_rnid -i "$ID_PATH" -S -r "$INV" -w "$RSM_PATH" -f
+echo "sign-tree-rsm.sh: wrote $RSM_PATH"
diff --git a/scripts/ci/tree-manifest.sh b/scripts/ci/tree-manifest.sh
new file mode 100755
index 00000000..c6d41a08
--- /dev/null
+++ b/scripts/ci/tree-manifest.sh
@@ -0,0 +1,154 @@
+#!/bin/sh
+# Generate or verify a byte-level SHA-256 inventory of git-tracked files.
+#
+# Format (sha256sum style, deterministic):
+# # meshchatx tree manifest v1
+# <sha256-hex> <path>
+#
+# meshchatx.rsm is excluded from the inventory (avoids a self-hash cycle).
+#
+# Usage:
+# tree-manifest.sh generate write inventory to stdout
+# tree-manifest.sh verify [inventory] verify against file or stdin
+# tree-manifest.sh verify-tracked [inv] also fail if tracked files are missing from inv
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+MANIFEST_HEADER="# meshchatx tree manifest v1"
+EXCLUDE_RSM="meshchatx.rsm"
+
+file_sha256_stream() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum | awk '{print $1}'
+ elif command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 | awk '{print $1}'
+ else
+ echo "tree-manifest.sh: need sha256sum or shasum" >&2
+ return 1
+ fi
+}
+
+file_sha256() {
+ f="$1"
+ file_sha256_stream <"$f"
+}
+
+# Hash the index blob (staged or HEAD), not unstaged working-tree dirt.
+index_sha256() {
+ f="$1"
+ git show ":$f" | file_sha256_stream
+}
+
+generate() {
+ printf '%s\n' "$MANIFEST_HEADER"
+ git ls-files -z | sort -z | while IFS= read -r -d '' f; do
+ [ "$f" = "$EXCLUDE_RSM" ] && continue
+ if ! git cat-file -e ":$f" 2>/dev/null; then
+ continue
+ fi
+ mode="$(git ls-files -s -- "$f" | awk '{print $1}')"
+ case "$mode" in
+ 100644 | 100755) ;;
+ *) continue ;;
+ esac
+ sum="$(index_sha256 "$f")"
+ printf '%s %s\n' "$sum" "$f"
+ done
+}
+
+load_inventory() {
+ inv_file="$1"
+ if [ "$inv_file" = "-" ] || [ -z "$inv_file" ]; then
+ cat
+ else
+ cat -- "$inv_file"
+ fi
+}
+
+verify() {
+ check_tracked="${1:-0}"
+ inv_src="${2:-}"
+ tmp="$(mktemp "${TMPDIR:-/tmp}/tree-manifest.XXXXXX")"
+ trap 'rm -f "$tmp" "$tmp.expect" "$tmp.actual" "$tmp.tracked"' EXIT INT
+ load_inventory "$inv_src" >"$tmp"
+
+ header="$(sed -n '1p' "$tmp")"
+ if [ "$header" != "$MANIFEST_HEADER" ]; then
+ echo "tree-manifest.sh: bad header: $header" >&2
+ return 1
+ fi
+
+ tmp_expect="${tmp}.expect"
+ tmp_actual="${tmp}.actual"
+ # Drop header and blank lines. normalize to "hash path"
+ sed '1d;/^$/d;/^#/d' "$tmp" | awk 'NF>=2 {print $1 " " substr($0, index($0,$2))}' | sort -k2 >"$tmp_expect"
+
+ fail=0
+ while IFS= read -r line; do
+ [ -z "$line" ] && continue
+ hash="${line%% *}"
+ path="${line#* }"
+ if [ ! -f "$path" ]; then
+ echo "tree-manifest.sh: missing: $path" >&2
+ fail=1
+ continue
+ fi
+ got="$(file_sha256 "$path")"
+ if [ "$got" != "$hash" ]; then
+ echo "tree-manifest.sh: modified: $path" >&2
+ echo " expected $hash" >&2
+ echo " got $got" >&2
+ fail=1
+ fi
+ done <"$tmp_expect"
+
+ if [ "$check_tracked" = "1" ]; then
+ tmp_tracked="${tmp}.tracked"
+ : >"$tmp_tracked"
+ git ls-files -z | sort -z | while IFS= read -r -d '' f; do
+ [ "$f" = "$EXCLUDE_RSM" ] && continue
+ [ -f "$f" ] || continue
+ [ -L "$f" ] && continue
+ printf '%s\n' "$f"
+ done | sort >"$tmp_tracked"
+
+ awk '{print substr($0, index($0,$2))}' "$tmp_expect" | sort >"$tmp_actual"
+ extra="$(comm -13 "$tmp_tracked" "$tmp_actual" || true)"
+ missing="$(comm -23 "$tmp_tracked" "$tmp_actual" || true)"
+ if [ -n "$extra" ]; then
+ echo "tree-manifest.sh: inventory has paths not tracked (or excluded):" >&2
+ printf '%s\n' "$extra" >&2
+ fail=1
+ fi
+ if [ -n "$missing" ]; then
+ echo "tree-manifest.sh: tracked files missing from inventory (added?):" >&2
+ printf '%s\n' "$missing" >&2
+ fail=1
+ fi
+ fi
+
+ if [ "$fail" -ne 0 ]; then
+ echo "tree-manifest.sh: verification failed" >&2
+ return 1
+ fi
+ echo "tree-manifest.sh: OK ($(wc -l <"$tmp_expect" | tr -d ' ') files)"
+}
+
+cmd="${1:-}"
+case "$cmd" in
+generate)
+ generate
+ ;;
+verify)
+ verify 0 "${2:-}"
+ ;;
+verify-tracked)
+ verify 1 "${2:-}"
+ ;;
+*)
+ echo "Usage: $0 generate | verify [file|-] | verify-tracked [file|-]" >&2
+ exit 2
+ ;;
+esac
diff --git a/scripts/ci/verify-tree-rsm.sh b/scripts/ci/verify-tree-rsm.sh
new file mode 100755
index 00000000..01774c80
--- /dev/null
+++ b/scripts/ci/verify-tree-rsm.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+# Verify meshchatx.rsm signature and byte-level file hashes.
+#
+# Env:
+# RNS_REQUIRED_SIGNER identity hash (default: e46112d44649266d71fe2193e00a4710)
+# RNS_RSM_PATH path to .rsm (default: meshchatx.rsm)
+# RNS_INVENTORY_OUT if set, write extracted inventory here (for end-of-job recheck)
+#
+# Usage:
+# sh scripts/ci/verify-tree-rsm.sh
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+SIGNER="${RNS_REQUIRED_SIGNER:-e46112d44649266d71fe2193e00a4710}"
+RSM_PATH="${RNS_RSM_PATH:-$ROOT/meshchatx.rsm}"
+HEADER="# meshchatx tree manifest v1"
+
+run_rnid() {
+ if command -v rnid >/dev/null 2>&1; then
+ rnid "$@"
+ elif [ -x "$ROOT/.venv/bin/rnid" ]; then
+ "$ROOT/.venv/bin/rnid" "$@"
+ elif command -v uv >/dev/null 2>&1; then
+ uv run rnid "$@"
+ else
+ echo "verify-tree-rsm.sh: rnid not found (install rns or sync the venv)" >&2
+ return 1
+ fi
+}
+
+if [ ! -f "$RSM_PATH" ]; then
+ echo "verify-tree-rsm.sh: missing $RSM_PATH" >&2
+ exit 1
+fi
+
+INV="$(mktemp "${TMPDIR:-/tmp}/tree-inv-verify.XXXXXX")"
+RAW="$(mktemp "${TMPDIR:-/tmp}/tree-rsm-raw.XXXXXX")"
+trap 'rm -f "$INV" "$RAW"' EXIT INT
+
+if ! run_rnid -i "$SIGNER" -V "$RSM_PATH" >"$RAW" 2>/dev/null; then
+ echo "verify-tree-rsm.sh: RSM signature verification failed" >&2
+ exit 1
+fi
+
+# Keep only the embedded inventory (starts at the manifest header line).
+awk -v h="$HEADER" 'BEGIN{p=0} $0==h{p=1} p{print}' "$RAW" >"$INV"
+if [ ! -s "$INV" ]; then
+ echo "verify-tree-rsm.sh: could not extract inventory from RSM" >&2
+ exit 1
+fi
+
+if [ -n "${RNS_INVENTORY_OUT:-}" ]; then
+ cp "$INV" "$RNS_INVENTORY_OUT"
+fi
+
+sh "$ROOT/scripts/ci/tree-manifest.sh" verify-tracked "$INV"
+echo "verify-tree-rsm.sh: OK (signer $SIGNER)"
diff --git a/scripts/ci/verify-workspace-clean.sh b/scripts/ci/verify-workspace-clean.sh
new file mode 100755
index 00000000..4a532c71
--- /dev/null
+++ b/scripts/ci/verify-workspace-clean.sh
@@ -0,0 +1,77 @@
+#!/bin/sh
+# Fail if tracked file bytes changed vs a saved inventory, or unexpected
+# untracked files appeared (GitHub runner mutation check).
+#
+# Usage:
+# verify-workspace-clean.sh <inventory-file>
+#
+# Env:
+# RNS_CLEAN_ALLOW space-separated path prefixes always ignored (optional)
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+INV="${1:?inventory file}"
+if [ ! -f "$INV" ]; then
+ echo "verify-workspace-clean.sh: missing inventory: $INV" >&2
+ exit 1
+fi
+
+sh "$ROOT/scripts/ci/tree-manifest.sh" verify "$INV"
+
+# Default ephemeral prefixes created by CI / local builds
+ALLOW="node_modules/ .pnpm-store/ .venv/ .venv-x64/ dist/ build/ electron/build/ meshchatx/public/ python-dist/ playwright-report/ mutants/ coverage/ .flatpak-builder/ parts/ prime/ stage/ android/.gradle/ android/app/build/ android/build/ android/vendor/ .cache/ __pycache__/ .pytest_cache/ vendor/offline/"
+ALLOW="$ALLOW ${RNS_CLEAN_ALLOW:-}"
+
+is_allowed() {
+ p="$1"
+ for a in $ALLOW; do
+ case "$p" in
+ "$a" | "$a"*)
+ return 0
+ ;;
+ esac
+ done
+ case "$p" in
+ *.log | *.tmp | *.swp | *.egg-info | *.pyc)
+ return 0
+ ;;
+ esac
+ return 1
+}
+
+fail=0
+tmp="$(mktemp "${TMPDIR:-/tmp}/ws-clean.XXXXXX")"
+trap 'rm -f "$tmp"' EXIT INT
+git status --porcelain -u --ignored=no >"$tmp" 2>/dev/null || git status --porcelain -u >"$tmp"
+while IFS= read -r line; do
+ [ -z "$line" ] && continue
+ xy="$(printf '%s\n' "$line" | cut -c1-2)"
+ path="$(printf '%s\n' "$line" | sed 's/^.. //;s/.* -> //')"
+ case "$xy" in
+ "??")
+ if is_allowed "$path"; then
+ continue
+ fi
+ echo "verify-workspace-clean.sh: unexpected untracked: $path" >&2
+ fail=1
+ ;;
+ *)
+ if [ "$path" = "meshchatx.rsm" ]; then
+ continue
+ fi
+ if is_allowed "$path"; then
+ continue
+ fi
+ echo "verify-workspace-clean.sh: unexpected change: $line" >&2
+ fail=1
+ ;;
+ esac
+done <"$tmp"
+
+if [ "$fail" -ne 0 ]; then
+ echo "verify-workspace-clean.sh: workspace not clean" >&2
+ exit 1
+fi
+echo "verify-workspace-clean.sh: OK"
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────